| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307 |
- 'use client';
- import { useState, useEffect, useCallback, useRef } from 'react';
- import { fetchApi } from '@/lib/utils/client';
- import { useStudioContext } from '@/app/studio/context';
- import type { CrewMemberItem, CrewMemberListResponse, SearchResultItem, SearchMemberResponse } from '@/types/response/crew/member';
- import { Button } from '@/components/ui/button';
- import { Input } from '@/components/ui/input';
- import { Label } from '@/components/ui/label';
- import { Checkbox } from '@/components/ui/checkbox';
- import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
- function RequiredLabel({ htmlFor, children }: { htmlFor: string; children: React.ReactNode }) {
- return <Label htmlFor={htmlFor}><span className="text-destructive mr-0.5">*</span>{children}</Label>;
- }
- const EMPTY_MEMBER_FORM = {
- nickname: '',
- role: '',
- sortOrder: 0,
- isActive: true
- };
- type Props = {
- crewID: number;
- };
- export default function CrewMembersTab({ crewID }: Props)
- {
- const { channelID } = useStudioContext();
- const [members, setMembers] = useState<CrewMemberItem[]>([]);
- const [loading, setLoading] = useState(true);
- // 초대 코드
- const [inviteCode, setInviteCode] = useState<string|null>(null);
- const [generatingCode, setGeneratingCode] = useState(false);
- // 회원 검색
- const [searchQuery, setSearchQuery] = useState('');
- const [searchResults, setSearchResults] = useState<SearchResultItem[]>([]);
- const [showSearch, setShowSearch] = useState(false);
- const searchTimer = useRef<ReturnType<typeof setTimeout>|null>(null);
- // 크루원 편집 모달
- const [editModal, setEditModal] = useState<{ open: boolean; member: CrewMemberItem|null }>({ open: false, member: null });
- const [editForm, setEditForm] = useState(EMPTY_MEMBER_FORM);
- const [saving, setSaving] = useState(false);
- const fetchMembers = useCallback(async () => {
- try {
- const res = await fetchApi<CrewMemberListResponse>(`/api/studio/crew/member/list/${crewID}`);
- setMembers(res.data?.list ?? []);
- } catch {
- } finally {
- setLoading(false);
- }
- }, [crewID]);
- useEffect(() => { fetchMembers(); }, [fetchMembers]);
- // 회원 검색 (디바운스)
- useEffect(() => {
- if (searchTimer.current) {
- clearTimeout(searchTimer.current);
- }
- if (searchQuery.trim().length < 2) {
- setSearchResults([]);
- return;
- }
- searchTimer.current = setTimeout(async () => {
- try {
- const res = await fetchApi<SearchMemberResponse>(
- `/api/studio/crew/member/search?channelID=${channelID}&q=${encodeURIComponent(searchQuery)}&crewID=${crewID}`
- );
- setSearchResults(res.data?.list ?? []);
- } catch {}
- }, 300);
- return () => {
- if (searchTimer.current) {
- clearTimeout(searchTimer.current);
- }
- };
- }, [searchQuery, channelID, crewID]);
- const handleAddMember = async (item: SearchResultItem) => {
- try {
- await fetchApi('/api/studio/crew/member/add', {
- method: 'POST',
- body: { crewID, targetMemberID: item.memberID, nickname: item.name ?? item.email, sortOrder: members.length }
- });
- setSearchQuery('');
- setSearchResults([]);
- setShowSearch(false);
- fetchMembers();
- } catch (err: unknown) {
- alert(err instanceof Error ? err.message : '추가에 실패했습니다.');
- }
- };
- const handleRemove = async (memberID: number) => {
- if (!confirm('이 크루원을 삭제하시겠습니까?')) {
- return;
- }
- try {
- await fetchApi(`/api/studio/crew/member/${memberID}`, { method: 'DELETE' });
- fetchMembers();
- } catch (err: unknown) {
- alert(err instanceof Error ? err.message : '삭제에 실패했습니다.');
- }
- };
- const openEdit = (member: CrewMemberItem) => {
- setEditForm({ nickname: member.nickname, role: member.role ?? '', sortOrder: member.sortOrder, isActive: member.isActive });
- setEditModal({ open: true, member });
- };
- const handleUpdate = async () => {
- if (!editModal.member) {
- return;
- }
- setSaving(true);
- try {
- await fetchApi(`/api/studio/crew/member/${editModal.member.id}`, {
- method: 'PUT',
- body: {
- crewMemberID: editModal.member.id,
- nickname: editForm.nickname,
- role: editForm.role || null,
- sortOrder: editForm.sortOrder,
- isActive: editForm.isActive
- }
- });
- setEditModal({ open: false, member: null });
- fetchMembers();
- } catch (err: unknown) {
- alert(err instanceof Error ? err.message : '수정에 실패했습니다.');
- } finally {
- setSaving(false);
- }
- };
- const handleGenerateCode = async () => {
- setGeneratingCode(true);
- try {
- const res = await fetchApi<{ inviteCode: string }>('/api/studio/crew/invite/generate', {
- method: 'POST',
- body: { crewID }
- });
- setInviteCode(res.data?.inviteCode ?? null);
- } catch (err: unknown) {
- alert(err instanceof Error ? err.message : '코드 생성에 실패했습니다.');
- } finally {
- setGeneratingCode(false);
- }
- };
- const copyCode = () => {
- if (inviteCode) {
- navigator.clipboard.writeText(inviteCode);
- alert('복사되었습니다.');
- }
- };
- if (loading) return <p className="studio-page__empty">준비 중...</p>;
- return (
- <>
- {/* 초대 코드 */}
- <fieldset className="crew-invite">
- <legend className="crew-invite__legend">초대 코드</legend>
- <div className="crew-invite__body">
- <Input
- readOnly
- value={inviteCode ?? ''}
- placeholder="코드를 생성해 주세요"
- className="crew-invite__input"
- />
- <div className="crew-invite__actions">
- {inviteCode ? (
- <>
- <Button variant="outline" onClick={copyCode}>복사</Button>
- <Button variant="outline" onClick={handleGenerateCode} disabled={generatingCode}>
- {generatingCode ? '생성 중...' : '재생성'}
- </Button>
- </>
- ) : (
- <Button onClick={handleGenerateCode} disabled={generatingCode}>
- {generatingCode ? '생성 중...' : '코드 생성'}
- </Button>
- )}
- </div>
- </div>
- </fieldset>
- {/* 크루원 관리 */}
- <div className="crew-members">
- <div className="crew-members__toolbar pb-3">
- <h2 className="crew-members__subtitle">크루원 ({members.length}명)</h2>
- <Button size="sm" onClick={() => setShowSearch(!showSearch)}>
- {showSearch ? '닫기' : '+ 크루원 추가'}
- </Button>
- </div>
- {showSearch && (
- <div className="member-search" style={{ marginBottom: 16 }}>
- <Input className="member-search__input" placeholder="별명 또는 이메일로 검색 (2자 이상)" value={searchQuery} onChange={e => setSearchQuery(e.target.value)} />
- {searchResults.length > 0 && (
- <div className="member-search__results">
- {searchResults.map(item => (
- <button type="button" key={item.memberID} className="member-search__item" onClick={() => handleAddMember(item)}>
- {item.thumb ? <img src={item.thumb} alt="" className="member-search__thumb" /> : <div className="member-search__thumb" />}
- <div className="member-search__info">
- <div className="member-search__name">{item.name ?? '(이름 없음)'}</div>
- <div className="member-search__email">{item.email}</div>
- {item.channelName && <div className="member-search__channel">{item.channelName}</div>}
- </div>
- </button>
- ))}
- </div>
- )}
- </div>
- )}
- <div className="studio-page__table-wrap">
- <table className="studio-page__table">
- <thead>
- <tr><th>순서</th><th>닉네임</th><th>역할</th><th>채널</th><th>상태</th><th>가입일</th><th>작업</th></tr>
- </thead>
- <tbody>
- {members.length === 0 ? (
- <tr><td colSpan={7} className="studio-page__empty">등록된 크루원이 없습니다.</td></tr>
- ) : members.map(m => (
- <tr key={m.id}>
- <td>{m.sortOrder}</td>
- <td>{m.thumb && <img src={m.thumb} alt="" className="member-row__thumb" />}{m.nickname}</td>
- <td>{m.role ?? '-'}</td>
- <td>{m.channelName ?? '-'}</td>
- <td><span className={`studio-page__badge studio-page__badge--${m.isActive ? 'active' : 'inactive'}`}>{m.isActive ? '활성' : '비활성'}</span></td>
- <td>{new Date(m.joinedAt).toLocaleDateString('ko-KR')}</td>
- <td>
- <div className="studio-page__actions">
- <Button variant="outline" size="sm" onClick={() => openEdit(m)}>수정</Button>
- <Button variant="destructive" size="sm" onClick={() => handleRemove(m.id)}>삭제</Button>
- </div>
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- </div>
- </div>
- {/* 크루원 편집 모달 */}
- <Dialog open={editModal.open} onOpenChange={open => { if (!open) setEditModal({ open: false, member: null }); }}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>크루원 수정</DialogTitle>
- </DialogHeader>
- <div className="space-y-4">
- <div className="space-y-2">
- <RequiredLabel htmlFor="member-nickname">닉네임</RequiredLabel>
- <Input id="member-nickname" value={editForm.nickname} onChange={e => setEditForm(f => ({ ...f, nickname: e.target.value }))} />
- </div>
- <div className="space-y-2">
- <Label htmlFor="member-role">역할</Label>
- <Input id="member-role" value={editForm.role} onChange={e => setEditForm(f => ({ ...f, role: e.target.value }))} placeholder="예: 게이머, MC" />
- </div>
- <div className="space-y-2">
- <Label htmlFor="member-order">순서</Label>
- <Input id="member-order" type="number" min={0} value={editForm.sortOrder} onChange={e => setEditForm(f => ({ ...f, sortOrder: Number(e.target.value) }))} />
- </div>
- <div className="flex items-center gap-2">
- <Checkbox id="member-active" checked={editForm.isActive} onCheckedChange={v => setEditForm(f => ({ ...f, isActive: !!v }))} />
- <Label htmlFor="member-active">활성화</Label>
- </div>
- </div>
- <DialogFooter className="flex flex-row justify-center gap-2 sm:justify-center">
- <Button variant="outline" className="flex-1 sm:flex-none" onClick={() => setEditModal({ open: false, member: null })}>취소</Button>
- <Button className="flex-1 sm:flex-none" onClick={handleUpdate} disabled={saving}>{saving ? '저장 중...' : '저장'}</Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- </>
- );
- }
|